Day 22 我列了一張三框架的對照表,其中「重新渲染觸發時機」那一欄是這樣寫的:
| 框架 | 觸發方式 | 影響 |
|---|---|---|
| React | setState 呼叫 → 整個組件樹重新執行函式 |
可能不必要的重渲染 |
| Vue | 直接修改 ref.value → 響應式系統偵測 |
精確追蹤修改,只更新相關 DOM 節點 |
這張表描述的是現象,不是原因。讀者看完會知道「Vue 比較精準」,但不會知道「精準」是從哪裡來的,更不會知道 React 要付出什麼才能換到同樣的精準。
今天把那一格拆開。拆開之後你會發現,所謂「響應式系統」不是一個東西,是三個零件組起來的,而市面上每一個框架的差異,都可以用「這三個零件它做了哪幾個」來定位。

在談框架之前,得先知道語言本身給了什麼牌。答案是兩張,而且都有明確的限制。
一般的物件屬性存的是值,這叫資料屬性(data property):
const person = { name: 'Abby' }
person.name // 'Abby',存什麼拿什麼
但 JavaScript 還有第二種屬性,它存的不是值,是一段程式:
const person = {
_name: 'Abby',
get name () { // get 開頭 → 這叫取值器(getter)
console.log('有人讀了 name')
return this._name
},
set name (v) { // set 開頭 → 這叫設值器(setter)
console.log('有人寫了 name =', v)
this._name = v
}
}
白話解釋這段:別人以為自己在「讀一個屬性」,實際上 JavaScript 偷偷幫他呼叫了一個函式。 讀 person.name 會執行 get name() 這個函式,並把它的回傳值交給你;寫 person.name = 'Bob' 則會把 'Bob' 當參數丟進 set name(v)。
get 與 set 合稱存取器(accessor),這種屬性就叫存取器屬性。
_name 而不是 name這不是命名潔癖,是硬性需求。看這段:
const trap = {
get boom () { return this.boom } // 讀 boom 又觸發 boom 的 getter
}
trap.boom
白話解釋這段:getter 攔截的就是 boom 這個名字,所以函式內部再去讀 this.boom 會再次觸發同一個 getter,第二次又觸發第三次,永遠回不來。我在 demo 裡實際跑過,結果是:
RangeError: Maximum call stack size exceeded
所以必須換一個沒被攔截的名字實際存資料。_value、_name 這種底線開頭的屬性就是真正的倉庫,沒有底線的那個是給外面用的門面。這個雙層結構是所有 getter/setter 實作的標準寫法,等一下看 Vue 的 RefImpl 就會再遇到一次。
const obj = {
get name () { /* 只攔 name */ },
get age () { /* 想攔 age 要再寫一組 */ },
}
obj.newThing = 123 // 沒定義過它的 setter,完全攔不到
delete obj.name // 刪除也攔不到
白話解釋這段:你沒有辦法寫一組 getter 去攔「所有屬性」。 每個屬性都要事先把名字寫死。這叫具名屬性(named property),這就是 Vue 2 的根本限制 —— 它用 Object.defineProperty 在初始化時把每個欄位都掛一組 getter/setter,所以後來才新增的欄位它偵測不到,才需要 Vue.set() 這個 API 來補。
Proxy 就是來解決上面那個天花板的:
const proxy = new Proxy(target, {
get (obj, key) { // ← key 是參數,不是寫死的名字
console.log('有人讀了', key)
return Reflect.get(obj, key)
},
set (obj, key, value) {
console.log('有人寫了', key, '=', value)
return Reflect.set(obj, key, value)
}
})
白話解釋這段:Proxy 是在真實物件前面放一個長得一模一樣的替身,所有讀寫都要先經過替身。 關鍵差別在那個 key —— 它是參數,所以一個函式就吃下所有屬性,包括你事先不知道名字的、之後才新增的、甚至被刪掉的。
Reflect.get 與 Reflect.set 是配套的內建工具,用途是「用預設的方式完成這個操作」,避免你在攔截函式裡手動寫 obj[key] = value 而漏掉一些邊界行為(例如原型鏈上的 setter、this 綁定)。
Proxy 的攔截函式在規格裡有個正式名稱叫 trap(陷阱),一共可以定義 13 種。
兩種能力的差別:
| 存取器屬性(getter/setter) | Proxy | |
|---|---|---|
| 攔誰 | 一個名字一組,寫死在程式碼裡 | 一個函式吃全部,名字用參數傳進來 |
| 事先要知道名字嗎 | 要 | 不用 |
| 新增的屬性 | 攔不到 | 攔得到 |
| 刪除屬性 | 攔不到 | 攔得到 |
| 陣列索引賦值 | 攔不到 | 攔得到 |
| 能包住數字或字串嗎 | 不適用 | 不行,target 必須是物件 |
| 進規範的年份 | 2009(ES5) | 2015(ES6) |
| 能 polyfill 嗎 | 能 | 不能 |
最後兩列等一下會變成 React 做決定時的關鍵因素。
現在進入今天的主軸。「響應式」不是一個機制,是三個機制串起來的效果。
track()。trigger(),通知完丟進 queueJob() 排程。這三個名詞聽起來很抽象,但手寫出來只有二十幾行。下面這段是我 demo 裡的核心,每一行都標了它是哪個零件:
let activeEffect = null // 現在正在執行的 effect。「誰在讀」的答案就靠它。
const depsMap = new Map() // 訂閱清單。key 是屬性名,value 是讀過它的 effect 集合。
// ── 零件 b:依賴收集 ──
function track (key) {
if (!activeEffect) return // 沒有人在讀就不用記
if (!depsMap.has(key)) depsMap.set(key, new Set())
depsMap.get(key).add(activeEffect) // 把現在在跑的那個 effect 記進名單
}
// ── 零件 c:觸發更新 ──
function trigger (key) {
const subs = depsMap.get(key)
if (!subs) return // 沒人訂閱這個 key,什麼都不用做
for (const fn of subs) fn() // 只叫醒名單上的人
}
// ── 零件 a:攔截 ──
function reactive (target) {
return new Proxy(target, {
get (obj, key) {
track(key) // 零件 b 掛在「讀」的路徑上
return Reflect.get(obj, key)
},
set (obj, key, value) {
const ok = Reflect.set(obj, key, value)
trigger(key) // 零件 c 掛在「寫」的路徑上
return ok
}
})
}
這不是我自己想像的結構。Vue 官方文件「深入響應式系統」那一頁貼的示範碼,變數名幾乎一模一樣 —— 同樣有一個全域的 activeEffect,同樣在 track() 裡把它 add 進一個 Set,同樣在 trigger() 裡把那個 Set 全部叫起來。唯一的差別是真實的 Vue 用 WeakMap<target, Map<key, Set<effect>>> 三層結構,因為它要同時管很多個物件;我這裡只有一個物件,所以把最外層那個 WeakMap 拿掉,壓成單層的 Map<key, Set<effect>>。
白話解釋這整段:reactive() 把你的物件換成一個會記帳的替身。替身在被讀的時候記下「是誰讀的」,在被寫的時候查帳本「剛才誰讀過這個欄位」,然後只打電話給那幾個人。activeEffect 這個全域變數就是整套機制的靈魂 —— 因為 JavaScript 是單執行緒,同一時間只會有一個 effect 在跑,所以「現在正在跑的是誰」永遠是個確定的答案,這才讓自動依賴收集成為可能。
實測結果(node day23-three-parts-of-reactivity.js,Node.js v22.23.2,2026-09-24 執行):
改了沒人讀的 unrelated:effect 重跑 0 次(訂閱清單裡沒有它)
改了有人讀的 count :effect 重跑 1 次,畫面:count = 1
改了沒人讀的欄位,重跑 0 次。 這一行就是「精準」的全部內容。
這是今天最值得看的一個對照組。我把攔截留著,只把依賴收集拿掉:
function reactiveWithoutTrack (target) {
return new Proxy(target, {
get (obj, key) { return Reflect.get(obj, key) }, // 攔得到,但什麼都不記
set (obj, key, value) {
const ok = Reflect.set(obj, key, value)
for (const fn of noTrackSubs) fn() // 沒有名單可查,只能全部叫醒
return ok
}
})
}
白話解釋這段:因為沒有人記下「誰讀過哪個欄位」,寫入的時候就沒有名單可以查,唯一還能做的事就是把所有人都叫起來。實測:
→ 拆掉零件 b 之後,只改 a 一個欄位,三個 effect 全部重跑了 3 次
只有零件 a 不算響應式系統,那只是「可以攔截」。 三個湊齊才是。這也正好解釋了為什麼 React 明明可以在某處插個攔截層,卻還是得整個重跑 —— 缺的不是攔截,是名單。
new Proxy(0, {})
// TypeError: Cannot create proxy with a non-object as target or handler
白話解釋這段:數字、字串、布林都不是物件,Proxy 沒有東西可以包。這就是 Vue 的 ref() 要你寫 .value 的唯一原因 —— 它得先造一個物件出來,把值塞進一個具名屬性,再回頭用 ES5 的 getter/setter 攔那個屬性:
class RefImpl {
constructor (value) { this._value = value } // 真正存資料的地方
get value () { track('value'); return this._value } // 門面,讀它會被攔
set value (v) { this._value = v; trigger('value') } // 門面,寫它會被攔
}
白話解釋這段:value 就是 Vue 團隊挑的一個固定名字,寫死在這個類別裡,不是什麼關鍵字 —— 改叫 box 一樣能跑。而 _value 要有底線,理由就是第二節那個無限遞迴。
所以 Vue 3 的兩個 API 用的是兩種不同的攔截手段:reactive() 用 Proxy,ref() 用 getter/setter。但因為 b 和 c 都在,兩個都是完整的響應式系統。
逐個對。
const [count, setCount] = useState(0)
useState 回傳的 count 是一個普通的區域變數,不是 Proxy,也不是掛了 getter 的物件屬性。你在 JSX 裡寫 {count},那就只是讀一個變數 —— 這件事在 JavaScript 語言層面不會發出任何訊號,沒有任何鉤子可以掛。
因為 b 的前提是 a。既然讀取根本沒被攔下來,React 從頭到尾不知道「這個 state 被哪幾個 JSX 節點用到」,自然也沒有任何訂閱清單這種東西存在。
這裡我要更正自己在筆記裡寫過的一句話。 我之前整理的那張對照表把 React 的「有觸發更新嗎」直接標成 ❌,這是寫太滿了。
React 是有 scheduler 的 —— scheduler 套件、Lane 優先級模型、批次更新(batching)都存在,論複雜度還遠超過 Vue 的 queueJob。差別不在「有沒有排程」,而在排程的對象是誰:
| 觸發的對象 | 粒度 | |
|---|---|---|
Vue 的 trigger |
讀過這個 key 的那幾個 effect | 單一屬性 |
| React 的排程 | 這個 Fiber 節點與需要重算的子樹 | 整個元件 |
所以精確的說法是:React 沒有「攔截 → 收集 → 精準通知」這條鏈,它走的是「你喊一聲 → 我全部重算 → 我自己比對出差異」。
這兩條路線有正式名稱:
抽象講完了,來看數字。我做了一個 200 個欄位的儀表板,畫面上有 200 個節點,每個節點只讀一個欄位,然後只改其中 1 個欄位。
pull 模型(手寫的最小 React 模型)跑出來:
只改 1 個欄位(f7),pull 模型做了:
元件函式重跑 1 次
產生新節點 200 個
逐一比對節點 200 次
真正寫進 DOM 1 次
最後真的要改的只有 1 個節點,但它為了「找出是哪一個」付了 400 次的代價。 push 模型在同樣情境下是 1 次 —— 因為它在讀的時候就把答案記下來了。
再把規模與次數拉大,量時間(200 個欄位,連續 2000 次「只改其中 1 個」):
| 啟動(毫秒) | 更新(毫秒) | |
|---|---|---|
| push(三零件) | 0.10 ~ 0.16 | 1.4 ~ 7.4 |
| pull(重跑+比對) | 0.04 ~ 0.07 | 57 ~ 119 |
我跑了四輪,更新階段的倍數差在 16 倍到 66 倍之間跳動,變異很大。 我不打算挑一個漂亮的數字寫「快 66 倍」,因為那是挑出來的。誠實的結論只有一句:在「大量欄位、小量變動」這個特定形狀下,push 模型的更新成本明顯低於 pull 模型,數量級差一到兩個。
但這只是硬幣的一面。
如果 push 全面勝出,這篇文章就不用寫了。三個零件不是免費的。
讀 3,000,000 次屬性:普通物件 8.1 ~ 14.2 毫秒,Proxy 99.1 ~ 159.4 毫秒
四輪實測下來,經過 Proxy 讀一個屬性比讀普通物件慢 8 到 18 倍。這是因為每次讀取都要走一趟 get trap,而 trap 是一個真實的函式呼叫,引擎也難以對它做內聯快取(Inline Cache,引擎把「這個屬性在物件的哪個位置」記起來的最佳化)。
這筆錢是每一次讀取都在付,不管值有沒有變。React 的普通物件讀取則完全沒有這筆開銷。
同一份實測裡,push 的啟動階段是 pull 的 2.3 ~ 2.8 倍。因為它要建立 Proxy、跑一輪 effect 登記、建出整份訂閱清單。頁面剛載入那一瞬間,pull 是比較輕的。
depsMap 是真實存在的資料結構。欄位愈多、訂閱者愈多,帳本愈大,而且它的生命週期要跟著元件走 —— 元件卸載時沒清乾淨就是記憶體洩漏。React 沒有這份帳本,也就沒有這類問題。
這一點無法量化,但在大型專案裡最有感。
state.count++ // Vue:這一行會觸發什麼?要追進去才知道
setCount(c => c + 1) // React:這一行囉唆,但你 grep 得到所有狀態變動點
白話解釋這個對照:Vue 的寫法簡潔,代價是「哪裡改了狀態」這件事散落在程式碼各處且外觀上跟普通賦值沒兩樣;React 強迫你顯式呼叫一個函式,囉唆,但換來的是資料流可以被搜尋、被追蹤。
React 的取捨是:我在每次更新多付一點算力,換你在每次除錯少付很多時間。 你可以不同意這個取捨,但要知道它是一個取捨,不是一個疏漏。
補充兩個歷史因素:
React.memo 的淺比較,全都建立在「舊值還在、新值是另一個物件」這個前提上。Day 21 那個「回滾寫回的是整個世界」的問題,換成 Vue 的可變模型反而更難處理。因為沒有攔截層,React 判斷「有沒有變」的唯一依據就是比對參考(reference),用的是 Object.is。實測:
原地改:Object.is(舊, 新) = true → React 判定沒變,不重繪
給新物件:Object.is(舊, 新) = false → React 判定變了,重繪
user.name = 'Bob'
setUser(user) // 傳進去的還是同一個物件,Object.is 為 true
物件內容確實改了,但 React 看的不是內容,是這還是不是同一個盒子。你在同一個盒子裡換東西,它看不出來。
所以「不可變更新(immutable update)」不是 React 的風格潔癖,是它的偵測機制決定的必要條件。
最陰險的是這個 bug 有時候會動 —— 如果同一次事件裡還有別的 state 更新,元件被迫重新渲染,你會看到改後的值,於是誤以為程式碼是對的。等到某天單獨改它時才壞掉,而且完全不知道為什麼上次可以。
push陣列 push 之後 Object.is(舊, 新) = true → 一樣看不見
陣列展開之後 Object.is(舊, 新) = false → 看得見
記法:push、pop、splice、sort、reverse 都會原地改;map、filter、concat、slice 都回傳新陣列。 React 只吃後者。
淺複製後 profile 還是同一個嗎:true
→ 改 copied.profile.age,原始的 user.profile.age 也變成 99
白話解釋這段:{ ...user } 只複製第一層,第二層的 profile 複製的是那個參考,兩邊指向同一個物件。所以要一層一層來:
setUser({ ...user, profile: { ...user.profile, age: 99 } })
這也正是 Day 3 那篇「傳值 vs 傳址」的觀念在 React 裡的實際事故現場。
不是。響應式是「效果」,Proxy 是「手段」。
| 框架/API | 攔截用什麼 | 有零件 b 嗎 | 有零件 c 嗎 | 算響應式系統嗎 |
|---|---|---|---|---|
| Vue 2 | Object.defineProperty(getter/setter) |
有 | 有 | 是 |
Vue 3 reactive() |
Proxy | 有 | 有 | 是 |
Vue 3 ref() |
getter/setter | 有 | 有 | 是 |
| Solid / Signals | 函式呼叫(count()) |
有 | 有 | 是 |
| Svelte 5 runes | 編譯期改寫 | 有 | 有 | 是 |
| Angular Signals | 函式呼叫 | 有 | 有 | 是 |
React useState |
沒有攔截 | 沒有 | 有 scheduler,但不是以值為單位 | 不是 |
注意第 1 列與第 3 列:同樣是 getter/setter,Vue 2 和 Vue 3 的 ref() 都是完整的響應式系統。 所以「用什麼手段攔截」跟「算不算響應式」是兩回事 —— 差別在有沒有 b 和 c。
講「響應式就是 Proxy」在面試會被追問到說不下去,因為 Vue 2 沒有 Proxy 但一樣有響應式。
React Compiler v1.0 已於 2025-10-07 正式發布。 它會自動幫你插入 memoization,效果上很像「React 變聰明了」,但它不是響應式系統:
useMemo / useCallback / React.memo 的邏輯,甚至能做到手寫辦不到的條件式 memoization。所以它改變的是「重跑之後有多少東西需要重算」,不是「要不要重跑」。React 依然是 pull-based,只是那個 pull 的範圍被編譯器縮小了。
這其實是一個很聰明的第三條路:push 把成本付在執行期(Proxy 的常態稅、訂閱清單的記憶體),React Compiler 把成本付在編譯期 —— 編譯只做一次,使用者的瀏覽器一毫秒都不用付。
還沒。TC39 的 Signals 提案目前在 Stage 1,提案本身的文件寫著 "It can currently be thought of as 'Stage 0'"。作者群明確表示要先做出多個產品級 polyfill、整合進主流框架之後才會申請推進階段。
它的架構就是標準化的三零件 —— 自動依賴發現、拓撲排序避免 glitch、computed 惰性求值。如果哪天它進了語言,零件 a 和 b 就變成引擎內建,那才是真正的分水嶺。但那一天還沒到,截至 2026 年 9 月,React 官方仍然沒有引入 signals,也沒有 runtime 的 reactivity。這是路線選擇,不是還沒做到。
useRef 的 .current 與 Vue 的 .value:兩個盒子,一個有祕書一個沒有這組對照是驗收今天內容最好的題目。
React 的 useRef |
Vue 的 ref |
|
|---|---|---|
| 盒子的屬性名 | .current |
.value |
| 那個屬性是什麼 | 普通的資料屬性 | 存取器屬性(有 getter/setter) |
| 改了會怎樣 | 什麼都不會發生 | 自動觸發畫面更新 |
| 為什麼要有盒子 | 需要一個跨多次渲染不變的容器 | 因為 Proxy 包不住原始型別 |
| 用途 | 存跨渲染的值,刻意不要觸發渲染 | 存響應式狀態,就是要觸發渲染 |
兩邊都需要盒子,但理由完全不同,這點很多文章會混在一起講:
new Proxy(0, {}) 會直接丟 TypeError,它得先造一個物件才有屬性可以攔。countRef.current = 999 // 值真的改了,但畫面一動也不動
這不是缺陷,這正是 useRef 存在的理由。 它的 .current 是普通資料屬性,沒有 getter,所以連「有人說變了」這個訊號都不發。當你需要存一個「跨渲染保留、但改了不該重畫」的東西 —— 計時器 ID、DOM 節點、上一次的值 —— 就用它。
順帶一提 Vue 那邊對應的坑:在 <script> 裡漏寫 .value:
const count = ref(0)
count++ // 你在對一個物件做加法
沒開 TypeScript 的話不會報錯,count 會安靜地變成 NaN。開了 TS 就會直接紅字擋下 —— 這是 TS 在 Vue 專案裡最實際的價值之一。
檔名:day23-three-parts-of-reactivity.js
執行:node day23-three-parts-of-reactivity.js
/**
* Day 23 demo:攔截、收集、觸發 —— 一個響應式系統的三個零件
*
* Part A —— 存取器屬性是「讀一個屬性卻執行了一段程式」
* Part B —— 三零件湊齊才叫響應式,缺一個是什麼下場
* Part C —— React 的 pull 模型在同一個情境下實際多做了幾次工
* Part D —— 規模拉到 200 個欄位時兩種模型的工作量差距(實測毫秒)
* Part E —— 為什麼「原地改物件」在 React 眼裡等於什麼都沒發生
*
* 全部都是手寫的最小模型,不是 React 或 Vue 的原始碼。
*/
'use strict'
const line = (t = '') => console.log(t)
const title = (t) => { line(); line('━'.repeat(64)); line(t); line('━'.repeat(64)) }
// ══════════════════════════════════════════════════════════
// Part A:存取器屬性 —— 「讀」這個動作本身可以被劫持
// ══════════════════════════════════════════════════════════
title('Part A:存取器屬性 —— 讀一個屬性,其實執行了一段程式')
let readCount = 0
const person = {
_name: 'Abby',
// get 開頭的屬性叫取值器(getter)。它不存值,存的是一段程式。
get name () {
readCount++
return this._name
},
// set 開頭的屬性叫設值器(setter)。賦值時會被它接走。
set name (v) {
this._name = v
}
}
person.name
person.name
person.name = 'Bob'
line(`讀了兩次 person.name,getter 被執行了 ${readCount} 次,目前的值是 ${person.name}`)
line('→ 到這裡 readCount 變成 3,因為上一行的樣板字串又讀了一次')
// get name() { return this.name } 會讀到自己 → 再觸發 getter → 無限遞迴。
const trap = {
get boom () { return this.boom }
}
try {
trap.boom
} catch (err) {
line(`→ get boom() { return this.boom } 的下場:${err.constructor.name}: ${err.message}`)
}
// ══════════════════════════════════════════════════════════
// Part B:三個零件 —— 攔截、依賴收集、觸發更新
// ══════════════════════════════════════════════════════════
title('Part B:三個零件湊齊才叫響應式')
let activeEffect = null // 現在正在執行的 effect
const depsMap = new Map() // 訂閱清單
let trackCalls = 0
let triggerCalls = 0
let effectRuns = 0
// ── 零件 b:依賴收集 ──
function track (key) {
trackCalls++
if (!activeEffect) return
if (!depsMap.has(key)) depsMap.set(key, new Set())
depsMap.get(key).add(activeEffect)
}
// ── 零件 c:觸發更新 ──
function trigger (key) {
triggerCalls++
const subs = depsMap.get(key)
if (!subs) return
for (const fn of subs) fn()
}
// ── 零件 a:攔截 ──
function reactive (target) {
return new Proxy(target, {
get (obj, key) {
track(key)
return Reflect.get(obj, key)
},
set (obj, key, value) {
const ok = Reflect.set(obj, key, value)
trigger(key)
return ok
}
})
}
// 把一個函式註冊成 effect:先把自己設成 activeEffect,再跑一次讓它去讀資料。
function effect (fn) {
const wrapped = () => {
effectRuns++
activeEffect = wrapped
fn()
activeEffect = null
}
wrapped()
return wrapped
}
const state = reactive({ count: 0, unrelated: 'x' })
let lastPainted = null
effect(() => { lastPainted = `畫面:count = ${state.count}` })
const runsAfterRegister = effectRuns
state.unrelated = 'y'
line(`改了沒人讀的 unrelated:effect 重跑 ${effectRuns - runsAfterRegister} 次(訂閱清單裡沒有它)`)
state.count = 1
line(`改了有人讀的 count :effect 重跑 ${effectRuns - runsAfterRegister} 次,${lastPainted}`)
line(`零件 b track 共 ${trackCalls} 次,零件 c trigger 共 ${triggerCalls} 次`)
// ── 拆掉零件 b,看看會怎樣 ──
let noTrackEffectRuns = 0
const noTrackSubs = new Set()
function reactiveWithoutTrack (target) {
return new Proxy(target, {
get (obj, key) { return Reflect.get(obj, key) },
set (obj, key, value) {
const ok = Reflect.set(obj, key, value)
for (const fn of noTrackSubs) fn()
return ok
}
})
}
const s2 = reactiveWithoutTrack({ a: 0, b: 0, c: 0 })
noTrackSubs.add(() => { noTrackEffectRuns++ })
noTrackSubs.add(() => { noTrackEffectRuns++ })
noTrackSubs.add(() => { noTrackEffectRuns++ })
s2.a = 1
line(`→ 拆掉零件 b 之後,只改 a 一個欄位,三個 effect 全部重跑了 ${noTrackEffectRuns} 次`)
// ── 零件 a 的天花板:Proxy 包不住原始型別 ──
try {
new Proxy(0, {})
} catch (err) {
line(`→ new Proxy(0, {}) 的下場:${err.constructor.name}: ${err.message}`)
}
// Vue 的 ref 怎麼繞過:先造一個物件,把值塞進具名屬性,再對那個屬性掛 getter/setter。
class RefImpl {
constructor (value) { this._value = value }
get value () { track('value'); return this._value }
set value (v) { this._value = v; trigger('value') }
}
const r = new RefImpl(0)
r.value = 5
line(`→ RefImpl 用具名屬性 value 繞過 Proxy 的限制,現在 r.value = ${r.value}`)
// ══════════════════════════════════════════════════════════
// Part C:React 的 pull 模型 —— 三個零件一個都不做
// ══════════════════════════════════════════════════════════
title('Part C:pull 模型 —— 不攔截、不收集、只重跑再比對')
function createPullRuntime (initialState, componentFn) {
let current = { ...initialState }
let prevTree = null
const stats = { componentRuns: 0, nodesCreated: 0, nodesDiffed: 0, domWrites: 0 }
// current 是一個普通物件,沒有 Proxy 也沒有 getter。
function render () {
stats.componentRuns++
const tree = componentFn(current, stats)
if (prevTree) {
for (let i = 0; i < tree.length; i++) {
stats.nodesDiffed++
if (prevTree[i] !== tree[i]) stats.domWrites++
}
} else {
stats.domWrites += tree.length
}
prevTree = tree
}
// setState 就是「主動通報」。React 沒有攔截層,所以這個呼叫本身就是訊號。
function setState (patch) {
current = { ...current, ...patch }
render()
}
render()
return { setState, stats }
}
const FIELDS = 200
const initial = {}
for (let i = 0; i < FIELDS; i++) initial['f' + i] = 0
function Dashboard (s, stats) {
const out = []
for (let i = 0; i < FIELDS; i++) {
stats.nodesCreated++
out.push('f' + i + ':' + s['f' + i])
}
return out
}
const pull = createPullRuntime(initial, Dashboard)
const afterMount = { ...pull.stats }
pull.setState({ f7: 1 })
line(`只改 1 個欄位(f7),pull 模型做了:`)
line(` 元件函式重跑 ${pull.stats.componentRuns - afterMount.componentRuns} 次`)
line(` 產生新節點 ${pull.stats.nodesCreated - afterMount.nodesCreated} 個`)
line(` 逐一比對節點 ${pull.stats.nodesDiffed - afterMount.nodesDiffed} 次`)
line(` 真正寫進 DOM ${pull.stats.domWrites - afterMount.domWrites} 次`)
line('→ 最後真的要改的只有 1 個節點,但它為了「找出是哪一個」付了 400 次的代價')
// ══════════════════════════════════════════════════════════
// Part D:規模量測 —— 兩種模型各自的成本結構
// ══════════════════════════════════════════════════════════
title('Part D:實測 —— push 省在更新,pull 省在啟動')
function benchPush (fields, updates) {
const localDeps = new Map()
let active = null
const obj = {}
for (let i = 0; i < fields; i++) obj['f' + i] = 0
const p = new Proxy(obj, {
get (o, k) {
if (active) {
if (!localDeps.has(k)) localDeps.set(k, new Set())
localDeps.get(k).add(active)
}
return Reflect.get(o, k)
},
set (o, k, v) {
const ok = Reflect.set(o, k, v)
const subs = localDeps.get(k)
if (subs) for (const fn of subs) fn()
return ok
}
})
const t0 = performance.now()
// 啟動階段:每個欄位註冊一個 effect,這是 push 模型要先付的錢。
for (let i = 0; i < fields; i++) {
const fn = () => { void p['f' + i] }
active = fn; fn(); active = null
}
const t1 = performance.now()
// 更新階段:每次只改一個欄位。
for (let u = 0; u < updates; u++) p['f' + (u % fields)] = u
const t2 = performance.now()
return { setup: t1 - t0, update: t2 - t1 }
}
function benchPull (fields, updates) {
let state = {}
for (let i = 0; i < fields; i++) state['f' + i] = 0
let prev = null
const renderOnce = () => {
const tree = new Array(fields)
for (let i = 0; i < fields; i++) tree[i] = 'f' + i + ':' + state['f' + i]
if (prev) for (let i = 0; i < fields; i++) { if (prev[i] !== tree[i]) { /* dom write */ } }
prev = tree
}
const t0 = performance.now()
renderOnce()
const t1 = performance.now()
for (let u = 0; u < updates; u++) {
state = { ...state, ['f' + (u % fields)]: u } // 不可變更新:複製一份新的
renderOnce()
}
const t2 = performance.now()
return { setup: t1 - t0, update: t2 - t1 }
}
const FIELDS_BENCH = 200
const UPDATES = 2000
// 先空跑一輪讓 JIT(Just-In-Time 即時編譯器)暖機,否則第一輪會被編譯成本汙染。
benchPush(FIELDS_BENCH, UPDATES); benchPull(FIELDS_BENCH, UPDATES)
const push = benchPush(FIELDS_BENCH, UPDATES)
const pullB = benchPull(FIELDS_BENCH, UPDATES)
line(`情境:${FIELDS_BENCH} 個欄位,連續 ${UPDATES} 次「只改其中 1 個」`)
line('')
line(' 啟動(毫秒) 更新(毫秒)')
line(`push(三零件) ${push.setup.toFixed(2).padStart(8)} ${push.update.toFixed(2).padStart(8)}`)
line(`pull(重跑+比對) ${pullB.setup.toFixed(2).padStart(8)} ${pullB.update.toFixed(2).padStart(8)}`)
line('')
line(`更新階段的倍數差:pull 是 push 的 ${(pullB.update / push.update).toFixed(1)} 倍`)
line(`啟動階段的倍數差:push 是 pull 的 ${(push.setup / pullB.setup).toFixed(1)} 倍`)
// Proxy 本身的讀取成本:這是 push 模型隱形的常態稅。
const plain = { v: 1 }
const wrapped = new Proxy({ v: 1 }, { get: (o, k) => Reflect.get(o, k) })
const N = 3_000_000
let sink = 0
let t = performance.now(); for (let i = 0; i < N; i++) sink += plain.v
const plainMs = performance.now() - t
t = performance.now(); for (let i = 0; i < N; i++) sink += wrapped.v
const proxyMs = performance.now() - t
line('')
line(`讀 ${N.toLocaleString('en-US')} 次屬性:普通物件 ${plainMs.toFixed(1)} 毫秒,Proxy ${proxyMs.toFixed(1)} 毫秒(${(proxyMs / plainMs).toFixed(1)} 倍)`)
line(`(sink = ${sink},只是避免引擎把整個迴圈最佳化掉)`)
// ══════════════════════════════════════════════════════════
// Part E:為什麼原地改物件等於什麼都沒發生
// ══════════════════════════════════════════════════════════
title('Part E:Object.is 看的是盒子,不是盒子裡的東西')
const user = { name: 'Abby', profile: { age: 20 } }
const mutated = user
mutated.name = 'Bob'
line(`原地改:Object.is(舊, 新) = ${Object.is(user, mutated)} → React 判定沒變,不重繪`)
const copied = { ...user, name: 'Cathy' }
line(`給新物件:Object.is(舊, 新) = ${Object.is(user, copied)} → React 判定變了,重繪`)
// 淺複製的陷阱:第一層是新的,第二層還是同一個參考。
line(`淺複製後 profile 還是同一個嗎:${Object.is(user.profile, copied.profile)}`)
copied.profile.age = 99
line(`→ 改 copied.profile.age,原始的 user.profile.age 也變成 ${user.profile.age}`)
line(' 所以巢狀物件要一層一層複製:{ ...user, profile: { ...user.profile, age: 99 } }')
// 陣列同理:push 是原地改,map / filter / concat 才回傳新陣列。
const todos = [{ id: 1 }]
const pushed = todos
pushed.push({ id: 2 })
line(`陣列 push 之後 Object.is(舊, 新) = ${Object.is(todos, pushed)} → 一樣看不見`)
const spread = [...todos, { id: 3 }]
line(`陣列展開之後 Object.is(舊, 新) = ${Object.is(todos, spread)} → 看得見`)
line()
line('━'.repeat(64))
line('結論:push 用「記帳」換更新時的精準,pull 用「重算」換啟動時的輕與資料流的顯式。')
line(' React 三個零件一個都不做,不是做不到,是它把成本放在另一邊。')
line('━'.repeat(64))
這幾題是把今天的零件拆開來各自手寫一遍,做完會比看十篇文章有用。
depsMap 跟 EventEmitter 的 listeners 是同一個東西。Symbol.toPrimitive,跟零件 a 同一條「攔截語言行為」的線。一、有外部出處的部分
| 內容 | 出處 |
|---|---|
| React Compiler 是 build-time 工具、做自動 memoization、可做條件式 memoization | React 官方部落格:https://react.dev/blog/2025/10/07/react-compiler-1 (發布日 2025-10-07) |
Object.is 的比較語意 |
MDN:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is |
| Proxy 的 target 必須是物件、13 種 trap | MDN:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy |
| getter/setter 語法與存取器屬性定義 | MDN:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/get |
Vue 的 track() / trigger() 與依賴收集流程 |
Vue 官方文件,深入響應式系統:https://vuejs.org/guide/extras/reactivity-in-depth.html |
Vue 2 用 Object.defineProperty、新增屬性偵測不到 |
Vue 2 官方文件,深入響應式原理:https://v2.vuejs.org/v2/guide/reactivity.html |
React 不可變更新與 useRef 不觸發重繪 |
React 官方文件:https://react.dev/learn/updating-objects-in-state / https://react.dev/reference/react/useRef |
| Signals 提案在 Stage 1、"can currently be thought of as Stage 0"、自動依賴發現與 glitch-free | TC39 提案 repo:https://github.com/tc39/proposal-signals |
| React scheduler 套件實際存在 | React 原始碼:https://github.com/facebook/react/tree/main/packages/scheduler |
二、我實際跑出來的部分
第三、四、五、六、七節所有的次數與毫秒數,全部由 day23-three-parts-of-reactivity.js 實測產生(Node.js v22.23.2,2026-09-24 執行,連跑四輪),可以重跑驗證。包含:
Object.is 在原地改/新物件/淺複製/陣列 push 四種情況的結果Part B 到 Part D 的 reactive、track、trigger、createPullRuntime 全是我手寫的最小模型,不是 Vue 或 React 的原始碼。 它們只示範三個零件各自負責什麼,真實實作牽涉排程、批次、並行 render 與 glitch 處理,複雜得多。
三、我自己的整理與判斷(沒有外部出處)
四、我沒有實作驗證的部分
五、一個要講清楚的量測限制
第五節的 push 與 pull 都是我手寫的最小模型,不是 Vue 與 React 本身。真實的 React 有 Fiber、批次更新、時間切片,真實的 Vue 有 effect 排程與去重,兩邊的實際數字都會跟這裡不同。這組數字要證明的是成本結構的方向(push 省在更新、pull 省在啟動),不是「Vue 比 React 快 N 倍」。拿這組數字去說哪個框架比較快,是誤用。
(查閱日期:2026-09-24。程式碼實測於 Node.js v22.23.2)
今天講的是 React 不做什麼。明天 Day 24 反過來看它做了什麼 —— 直接打開 React 原始碼,讀 Component.prototype.setState 那幾行,以及為什麼 React 內部到處都是 hasOwnProperty.call(obj, key) 而不是 obj.hasOwnProperty(key)。
那個 .call 的寫法,就是 Day 10 講的靜態方法與實例方法的分別在真實專案裡的樣子。